/* vim: set ts=2 et sw=2 cindent fo=qroca: */

package com.globant.google.mendoza.malbec.transport;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.URL;
import java.net.HttpURLConnection;

import javax.net.ServerSocketFactory;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocketFactory;

import org.apache.commons.codec.binary.Base64;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/** Implements a stand alone https/https transport.
 *
 * This is a test transport, not adequate for production installations.<p>
 *
 * This implementation starts a thread to listen to the configure port and then
 * it starts a new thread per each request.<p>
 *
 */
public final class StandAlone implements Transport {

  /** The default http over ssl port.
   */
  private static final int HTTPS_PORT = 443;

  /** The read buffer size for the sending end of the transport.
   */
  private static final int READ_BUFFER_SIZE = 1024;

  /** The class logger.
  */
  private static Log log = LogFactory.getLog(StandAlone.class);

  /** The url to connect to.
   */
  private String serverURL = "https://localhost:10001";

  /** The port to listen to connections from google.
   */
  private int port = HTTPS_PORT;

  /** The ssl socket factory used to create connections as a client.
   */
  private SSLSocketFactory clientSocketFactory;

  /** The ssl socket factory used to create connections as a server.
   */
  private ServerSocketFactory serverSocketFactory;

  /** The merchant id used for authentication.
   */
  private String merchantId;

  /** The merchant key used for authentication.
   */
  private String merchantKey;

  /** The registered receiver of the GBuy requests.
   */
  private Receiver receiver = null;

  /** The mini http server implementation.
   */
  private NanoHTTPD server = null;

  /** The error message read from the server. This is only valid if ...
   */
  private String sendErrorMessage = null;

  /** Specifies if all certificates are accepted whether the truststore is
   *  specified or not.
   */
  private boolean acceptAllCertificates;

  /** Builds an instance of the stand alone transport.
   *
   * This transport listens on port 443.
   *
   * @param theClientSocketFactory The socket factory to use to create client
   * sockets. It is used to specify a trust certificate specific for this
   * trasport. If null, it uses the jvn default trust certificates.
   *
   * @param theServerSocketFactory The socket factory to use to create a
   * listening socket. It presents the specified certificate to the server.
   *
   * @param theMerchantId The merchant id to use.
   *
   * @param theMerchantKey The merchant key to use.
   *
   * @param url The server url to connect to when sending messages. This is the
   * GBuy service url.
   */
  public StandAlone(final SSLSocketFactory theClientSocketFactory,
      final ServerSocketFactory theServerSocketFactory,
      final String theMerchantId, final String theMerchantKey,
      final String url) {

    if (theMerchantId == null) {
      throw new IllegalArgumentException("the merchant id cannot be null");
    }
    if (theMerchantKey == null) {
      throw new IllegalArgumentException("the merchant key cannot be null");
    }
    if (url == null) {
      throw new IllegalArgumentException("the server url cannot be null");
    }

    serverURL = url;
    merchantId = theMerchantId;
    merchantKey = theMerchantKey;
    clientSocketFactory = theClientSocketFactory;
    serverSocketFactory = theServerSocketFactory;
  }

  /** Builds an instance of the stand alone transport.
   *
   * This constructor does not request the url. You must call setServerURL
   * before sending messages.
   *
   * @param theClientSocketFactory The socket factory to use to create client
   * sockets. It is used to specify a trust certificate specific for this
   * trasport. If null, it uses the jvn default trust certificates.
   *
   * @param theServerSocketFactory The socket factory to use to create a
   * listening socket. It presents the specified certificate to the server.
   *
   * @param theMerchantId The merchant id to use.
   *
   * @param theMerchantKey The merchant key to use.
   *
   * @param thePort The port to listen to connections from google.
   */
  public StandAlone(final SSLSocketFactory theClientSocketFactory,
      final ServerSocketFactory theServerSocketFactory,
      final String theMerchantId, final String theMerchantKey,
      final int thePort) {

    if (theMerchantId == null) {
      throw new IllegalArgumentException("the merchant id cannot be null");
    }
    if (theMerchantKey == null) {
      throw new IllegalArgumentException("the merchant key cannot be null");
    }

    serverURL = null;
    merchantId = theMerchantId;
    merchantKey = theMerchantKey;
    clientSocketFactory = theClientSocketFactory;
    serverSocketFactory = theServerSocketFactory;
    port = thePort;
  }

  /** Builds an instance of the stand alone transport.
  *
  * @param theClientSocketFactory The socket factory to use to create client
  * sockets. It is used to specify a trust certificate specific for this
  * trasport. If null, it uses the jvn default trust certificates.
  *
  * @param theServerSocketFactory The socket factory to use to create a
  * listening socket. It presents the specified certificate to the server.
  *
  * @param theMerchantId The merchant id to use.
  *
  * @param theMerchantKey The merchant key to use.
  *
  * @param url The server url to connect to when sending messages. This is the
  * GBuy service url.
  *
  * @param thePort The port to listen to connections from google.
  *
  * @param isAcceptAllCertificates Indicates if all the certificates must be
  *  accepted.
  */
 public StandAlone(final SSLSocketFactory theClientSocketFactory,
     final ServerSocketFactory theServerSocketFactory,
     final String theMerchantId, final String theMerchantKey,
     final String url, final int thePort,
     final boolean isAcceptAllCertificates) {

   this(theClientSocketFactory, theServerSocketFactory, theMerchantId,
       theMerchantKey, url);

   port = thePort;
   acceptAllCertificates = isAcceptAllCertificates;
 }

  /** Gets the port that the server is listening on.
   *
   * Only call this function after start.
   *
   * @return Returns the port the server is listening on, usually the one set
   * with setPort, except when setPort is called with 0, in wich case it
   * returns the effective port used by the server.
   */
  public int getPort() {
    if (server == null) {
      throw new IllegalStateException("You must start the transport first");
    }
    return server.getPort();
  }

  /** Sets the url to use to send messages to GBuy.
   *
   * @param url The url. Cannot be null.
   */
  public void setServerURL(final String url) {
    if (url == null) {
      throw new IllegalArgumentException("url cannot be null");
    }
    serverURL = url;
  }

  /** Sends a message to the server through an ssl connection.
   *
   * @param message The message to send to the server. It cannot be null.
   *
   * @return Returns the server response, usually an acknowledge or an error
   * related to the validity of the message structure.
   */
  public String send(final String message) {

    log.trace("Entering send");

    HttpURLConnection connection = getConnection("application/xml");

    String response = null;
    log.debug("Writing message:\n" + message);
    try {
      writeRequest(connection, message);
      response = readResponse(connection);
    } finally {
      connection.disconnect();
    }

    if (response == null) {
      throw new RuntimeException(sendErrorMessage);
    }
    log.debug("Received response:\n" + response);

    log.trace("Leaving send");
    return response;
  }

  /** Registers a listener that receives messages from GBuy.
   *
   * For every message it receives, it calls receiver.receive. Implementors of
   * receive must return the message that wants to be sent to GBuy.
   *
   * The trasport starts to listen to server connections only when it has a
   * registered receiver.
   *
   * @param theReceiver The listener. It cannot be null.
   */
  public void registerReceiver(final Receiver theReceiver) {

    log.trace("Entering registerReceiver");

    if (theReceiver == null) {
      throw new IllegalArgumentException("receiver cannot be null");
    }
    receiver = theReceiver;

    log.trace("Leaving registerReceiver");
  }

  /** Starts the server.
   *
   * You must have registered a receiver before calling start.
   */
  public void start() {
    log.trace("Entering start");
    if (receiver == null) {
      throw new IllegalStateException("Must register a receiver before"
          + " starting the server");
    }
    server = new NanoHTTPD(port, receiver, serverSocketFactory, merchantId,
        merchantKey);
    log.trace("Leaving start");
  }

  /** Stops the server.
   *
   * You must have called start before calling this function.
   */
  public void stop() {
    if (server == null) {
      throw new RuntimeException("You must call start before stopping the"
          + " transport");
    }
    if (server != null) {
      server.stop();
      server = null;
    }
  }

  /** Gets a properly initialized connection to the server.
   *
   * @param contentType The content type that will be sent to the server. Can
   * be null, in which case no content type header is sent.
   *
   * @return Returns the connection. This function never returns null.
   */
  private HttpURLConnection getConnection(final String contentType) {

    log.trace("Entering getConnection");

    if (serverURL == null) {
      throw new IllegalStateException("Must set the server url first");
    }

    HttpURLConnection connection = null;

    try {

      URL url = new URL(serverURL);

      connection = (HttpURLConnection) url.openConnection();

      // Use basic authentication to validate to the server.
      String encoding = new String(Base64.encodeBase64((merchantId + ":"
            + merchantKey).getBytes()));
      connection.setRequestProperty("Authorization", "Basic " + encoding);

      if (acceptAllCertificates) {
       if (connection instanceof HttpsURLConnection) {
         HttpsURLConnection sslConnection = (HttpsURLConnection) connection;
         // Accepts all certificates.
         sslConnection.setHostnameVerifier(new HostnameVerifier() {
          public boolean verify(final String hostName,
              final SSLSession session) {
            // Simply returns true to accept all certificates no matter which
            // one is received.
            return true;
          }
         });

       }
      } else {
        // We configure the client to accept certificates signed by one of our
        // trusted entities.
        if (clientSocketFactory != null) {
          if (connection instanceof HttpsURLConnection) {
            HttpsURLConnection sslConnection = (HttpsURLConnection) connection;
            sslConnection.setSSLSocketFactory(clientSocketFactory);
          }
        }
      }

      connection.setDoOutput(true);
      if (contentType != null) {
        connection.setRequestProperty("Content-Type", contentType);
      }
    } catch (IOException e) {
      throw new RuntimeException("Unable to connect to server", e);
    }
    log.trace("Leaving getConnection");
    if (connection == null) {
      // Post condition.
      throw new RuntimeException("Attempted to return a null connection");
    }
    return connection;
  }

  /** Reads the data from the connection and returns it as a string.
   *
   * @param connection The connection to read the data from.
   *
   * @return Returns the string with the data read from the connection. It is
   * null if an error happens. Read the error message with getError().
   */
  private String readResponse(final HttpURLConnection connection) {
    log.trace("Entering readResponse");
    BufferedReader in = null;
    String response = null;
    try {
      in = new BufferedReader(new InputStreamReader(
            connection.getInputStream()));
    } catch (IOException e) {
      // An io exception. Read the error condition, if available.
      in = new BufferedReader(new InputStreamReader(
            connection.getErrorStream()));
      if (in != null) {
        sendErrorMessage = readResponse(in);
      }
      log.trace("Leaving readResponse with null");
      return null;
    }
    try {
      response = readResponse(in);
    } finally {
      if (in != null) {
        try {
          in.close();
        } catch (IOException e) {
          throw new RuntimeException("Error closing connection", e);
        }
      }
    }
    log.trace("Leaving readResponse");
    return response;
  }

  /** Reads the data from the stream realted to a connection and returns it as
   * a string.
   *
   * @param in The reader to read the data from.
   *
   * @return Returns the string with the data read from the connection.
   */
  private String readResponse(final BufferedReader in) {
    StringBuffer response = new StringBuffer();
    try {
      // Reads the response from the server.
      char [] line = new char[READ_BUFFER_SIZE];
      int read = 0;
      while ((read = in.read(line)) != -1) {
        response.append(line, 0, read);
      }
    } catch (IOException e) {
      log.error("Error reading data from the server", e);
      throw new RuntimeException("Unable to read response", e);
    }
    return response.toString();
  }

  /** Sends a request to the server.
   *
   * @param connection The connection to the server.
   *
   * @param message The message to send to the server.
   */
  private void writeRequest(final HttpURLConnection connection, final String
      message) {
    PrintWriter out = null;
    try {
      // Sends the message to the server.
      out = new PrintWriter(connection.getOutputStream());
      out.write(message);
      out.flush();
    } catch (Exception e) {
      log.error("Error sending data to the client", e);
      throw new RuntimeException("Unable to send message", e);
    } finally {
      if (out != null) {
          out.close();
      }
    }
  }
}

